Skip to content

add JS-snippet support using from: .module("/path/to/js-module.js") syntax - #792

Open
sliemeobn wants to merge 5 commits into
swiftwasm:mainfrom
sliemeobn:feature/js-snippets
Open

add JS-snippet support using from: .module("/path/to/js-module.js") syntax#792
sliemeobn wants to merge 5 commits into
swiftwasm:mainfrom
sliemeobn:feature/js-snippets

Conversation

@sliemeobn

@sliemeobn sliemeobn commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

allow to ship JS code as the source for imports (in addition to ".global" and explicitly provided imports)

@JSFunction(from: .module("/Modules/JSImportModule.mjs"))
func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int

@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs"))
func moduleRenamed() throws(JSException) -> String

@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs"))
var moduleVersion: String
// Modules/JSImportModule.mjs
export function moduleAdd(lhs, rhs) {
    return lhs + rhs;
}

export function renamedFunction() {
    return "renamed";
}

export const version = "1.0";

I noticed that currently there is no diagnostic when from: is used in places that do not support it, I did not add these checks (eg: class members, setters) to this PR more focussed. (#793)

@sliemeobn

Copy link
Copy Markdown
Contributor Author

closes #508

@krodak krodak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature — the design fits the existing patterns well and test coverage is strong. Details in the inline comments.

Two tiny things not worth their own threads: the registry error messages render a double slash (TestModule//Modules/x.mjs — path already starts with /), and a runtime test for a throwing module export would pin down the setException path too.

)
packageInputs.append(
make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in
let output = try linkBridgeJS(scope)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting this into a second task means linkBridgeJS (decode all skeletons + full codegen) runs twice on every build — even for the majority of projects with zero modules, which also end up with an empty bridge-js-modules/ directory in their output package.

I get why the split is attractive: a separate task output means cleanEverything removes the directory. Memoizing the link result keeps that benefit without the double work — skeletons can't change within one build() invocation and MiniMake runs tasks sequentially, so the cache is safe:

var cachedLinkOutput: BridgeJSLinkOutput?
let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in
    if let cachedLinkOutput { return cachedLinkOutput }
    var link = BridgeJSLink(
        sharedMemory: Self.isSharedMemoryEnabled(triple: triple)
    )
    for skeletonPath in skeletons {
        let url = URL(fileURLWithPath: scope.resolve(path: skeletonPath).path)
        try link.addSkeletonFile(data: Data(contentsOf: url))
    }
    let output = try link.link()
    cachedLinkOutput = output
    return output
}

for skeleton in skeletons {
for module in skeleton.imported?.modules ?? [] {
guard module.path.hasPrefix("/") else {
throw BridgeJSLinkError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relativePath is spliced straight into outputDir.appending(path:) in PackagingPlanner. makeJavaScriptModuleFileLookup guarantees no traversal for locally generated skeletons, so this is safe today — but skeletons also arrive from dependency packages' committed BridgeJS.json, and a path like /../../evil.js in one of those would write outside the output directory (and emit an escaping import line). Cheap to close here since we're already validating:

guard module.path.hasPrefix("/"),
    !module.path.split(separator: "/").contains("..")
else {
    throw BridgeJSLinkError(
        message: "JavaScript module path must start with '/' and must not contain '..': \(module.path)"
    )
}

public init(from decoder: any Decoder) throws {
let value = try decoder.singleValueContainer().decode(String.self)
self = value == "global" ? .global : .module(value)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encoding .module into the same single-value slot as .global is a nice backward-compatible trick, and the legacy-decode test covers it well. One downside: any unknown string (a future case, or a typo'd/hand-edited skeleton) silently decodes as .module("whatever"), and the failure surfaces much later as Missing embedded JavaScript module for Foo/whatever, which points nowhere useful. Since module paths are already required to start with /, the invalid case can fail loudly right at the boundary:

public init(from decoder: any Decoder) throws {
    let container = try decoder.singleValueContainer()
    let value = try container.decode(String.self)
    if value == "global" {
        self = .global
    } else if value.hasPrefix("/") {
        self = .module(value)
    } else {
        throw DecodingError.dataCorruptedError(
            in: container,
            debugDescription: "Unknown JSImportFrom value '\(value)'. Expected \"global\" or a '/'-rooted module path."
        )
    }
}

```

The leading `/` denotes the Swift target root, not the filesystem root. BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR itself had to add "Modules" to the exclude: list in Package.swift, and anyone following these docs will hit the same thing — SwiftPM's "found N file(s) which are unhandled" warning the moment they drop a .js file under their target. Worth calling out once here (and probably in the overview article too), e.g. right after this paragraph:

SwiftPM does not know what to do with `.js`/`.mjs` files inside a target, so exclude the directory holding them to avoid an "unhandled files" warning:

```swift
.target(
    name: "MyApp",
    exclude: ["JavaScript"],
    plugins: [.plugin(name: "BridgeJS", package: "JavaScriptKit")]
)
```

/// JavaScript files must be declared as build-command inputs so SwiftPM reruns
/// BridgeJS when a referenced module changes.
func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] {
guard

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every .js/.mjs anywhere under the target becomes a declared build-command input and is appended to the tool's argv, referenced or not. For a target that vendors JS assets (or has a stray node_modules), that's a lot of spurious rebuild triggers and a long command line — and a directory named foo.js currently passes the filter, only to fail later at String(contentsOf:) with a confusing error. The sibling recursivelyCollectSwiftFiles in BridgeJSTool.swift checks isRegularFile; doing the same here plus a small skip-list would cover it:

private let skippedDirectoryNames: Set<String> = ["node_modules"]

func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] {
    guard
        let enumerator = FileManager.default.enumerator(
            at: directory,
            includingPropertiesForKeys: [.isRegularFileKey],
            options: [.skipsHiddenFiles]
        )
    else { return [] }

    var files: [URL] = []
    for case let fileURL as URL in enumerator {
        if skippedDirectoryNames.contains(fileURL.lastPathComponent) {
            enumerator.skipDescendants()
            continue
        }
        guard isJavaScriptModuleFile(fileURL),
            (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
        else { continue }
        files.append(fileURL)
    }
    return files.sorted { $0.path < $1.path }
}

(.build and other dot-directories are already covered by .skipsHiddenFiles.)


You can bring JavaScript into Swift in two ways:
You can bring JavaScript into Swift in three ways:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny one: the intro at line 11 of this article still says imports work in "two ways", so the article now disagrees with itself:

You can import JavaScript APIs into Swift in three ways:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants